webpack 新手教學之淺談模組化與 snowpack


Posted by huli on 2020-01-22

前言

在我的部落格裡面,其實很少有工具類的教學文章。一來是因為這種工具類文章都大同小異,二來是我生性懶惰,許多手把手教學都需要鉅細靡遺外加豐富截圖,實在是不適合我。

但這次會來寫這個題目,是因為我覺得 webpack 是個新手不容易懂,就算懂了也不一定是真的懂的工具。或是換句話說,它是常常被誤解的一個工具。

這並不是 webpack 本身的問題,而是現在許多新手切入前端都直接從 React 或是 Vue 開始,而且都使用了他們各自提供的 CLI 工具,等到要客製化一些設定時才注意到:「阿,原來有個東西叫做 webpack」。

CLI 工具帶來了方便性,優點是讓新手能夠快速上手,不用去管那些繁瑣的設定;而缺點就是若是新手沒有意識到背後那些工具,等到哪一天工具壞了、不能用了或是有地方需要修改,就是噩夢的開始。

為了讓這種情況減少,我才決定寫這篇文章,希望從源頭帶大家認識 webpack 這項工具以及模組化的概念。你必須先知道什麼是模組,才能理解什麼是 webpack。

開頭我想先透過幾個問題讓大家思考自己對模組化以及 webpack 是否熟悉:

  1. 為什麼很多專案(例如說 React)在部署前都要先 build?這個步驟在幹嘛你知道嗎?
  2. 你知道 require/module.exportsimport/export 的差別嗎?
  3. 你知道 import 與 export 這兩個語法在瀏覽器上面不是隨便就能使用嗎?
  4. 你知道為什麼要用 webpack 嗎?
  5. 你知道 webpack 為什麼要有 loader 嗎?

這些問題應該會在讀這篇文章時慢慢有點靈感,在最後面時會幫大家解答。

模組化

相信大家應該都聽過模組這個詞,英文叫做 module,這邊先舉個簡單的例子。

例如說你的手機,螢幕壞掉了可以只換螢幕,相機壞掉了就換相機,電池壞掉了換電池。你可能會想說:「咦,那不然呢?」

你有想過為什麼可以這樣換嗎?因為螢幕跟相機是兩個完全獨立開來的功能,互不干擾也沒有相依性,所以換螢幕不會讓相機功能壞掉,反之亦然。這其實就是模組化的概念,螢幕是一個模組,只負責顯示資訊這項功能,而相機又是另外一個模組,負責拍照。透過手機的軟體把螢幕與相機整合起來,讓相機捕捉到的畫面在螢幕上顯示。

若是沒有模組化的概念,一整台手機就真的是一整台,每個功能都互相綁在一起,相機壞掉了,就要把整台手機都換掉,沒辦法只換相機。

如果以程式碼來講,大概會像這樣子:

import 相機
import 螢幕
import AA電池
import sim卡

Phone.start(相機, 螢幕, AA電池, sim卡)

模組化的好處之一就是方便抽換,若是今天要用別家的電池,就只要把電池那邊改掉就好了,其他地方都不用動:

import 相機
import 螢幕
import BB 電池
import sim卡

Phone.start(相機, 螢幕, BB 電池, sim卡)

寫程式的時候也是這樣子的,我們常常會用到許多系統內建的模組或者是別人寫的模組,以 Node.js 為例,可以使用內建的 os 模組,來獲取作業系統相關的資訊,例如說作業系統是哪個平台:

var os = require('os')
console.log(os.platform()) // darwin

在這邊我們使用了 require 將系統內建的 os 模組引入進來,並且呼叫 os.platform() 來取得資訊。

這就是在程式裡面最基礎的模組的使用。有了這個概念之後,我們可以把範圍縮小,來談談在 Node.js 裡面模組化是怎麼運作的。(我認為要理解模組化這個概念,對 Node.js 有基本的理解是很重要的,若是你完全不會的話很建議去學一點點,至少知道它在幹嘛)

Node.js 的模組

前面已經有提到在 Node.js 裡面我們可以使用 require 把內建模組給引入。那如果我們想要自己做一個模組該怎麼辦呢?用 module.exports 這個語法就可以達成。

例如說我們有個 utils.js,裡面有一個會常常用到的計算價格的函式 calculate

// utils.js
function calculate(n) { 
  return ((n * 100 + 20 - 4)) % 10 + 3  // 計算價格公式
}

module.exports = calculate // 把這個函式 export 出去

那我們在另外一個檔案 main.js,就可以使用 require 這個語法來引入:

// main.js
var calculate = require('./utils')
console.log(calculate(30)) // 9

我們在 utils.js 裡面 module.exports 什麼,在其他地方用 require('./utils') 就會引入什麼。所以你可以把 var calculate = require('./utils') 想成 var calculate = (utils.js 裡的)module.exports

再來看一個範例,我們把 utils.js 輸出的東西改成一個物件:

function calculate(n) { 
  return ((n * 100 + 20 - 4)) % 10 + 3  // 計算價格公式
}

module.exports = {
  cal: calculate,
  name: 'hello'
} // 把這個物件 export 出去

main.js 裡面,就一樣可以拿到這個物件:

var obj = require('./utils')
console.log(obj.cal(30)) // 9
console.log(obj.name) // hello

這就是在 Node.js 裡面最基本的模組使用的概念,用 module.exports 把東西導出,用 require 把模組引入。

上面所講的這一套模組機制,其實並不在 JavaScript 的規範裡面,而是一套叫做「CommonJS」的標準。

看到這邊相信應該有些人開始有點頭痛了,想說「蛤?這是什麼意思?」

大家設想一個情境就知道了。

讓我們先回到 ES6 出現之前,在那個年代 JavaScript 本身並沒有規範任何與模組相關的使用機制。

這時候大家就可以天馬行空提出自己的想法,例如說 A 可能會覺得那不如這樣寫:

// utils.js
function calculate(n) { 
  return ((n * 100 + 20 - 4)) % 10 + 3  // 計算價格公式
}

out = {
  cal: calculate,
  name: 'hello'
}

// main.js
var obj = include('./utils')
console.log(obj.cal(30)) // 9
console.log(obj.name) // hello

要輸出模組的時候用 out,要引入的時候用 include

而 B 也可以說這樣比較好:

// utils.js
function calculate(n) { 
  return ((n * 100 + 20 - 4)) % 10 + 3  // 計算價格公式
}

EXP = {
  cal: calculate,
  name: 'hello'
}

// main.js
var obj = in('./utils')
console.log(obj.cal(30)) // 9
console.log(obj.name) // hello

要輸出模組的時候用 EXP,要引入的時候用 in

所以 A 發想出來的規範就叫做 A 標準,B 的就叫 B 標準,這兩個標準都可以達成模組化,但是語法跟背後實作不一樣。而 CommonJS 就只是其中一個標準而已,這個標準就是在輸出模組時用 module.exports,引入時用 require

而後來 CommonJS 這個標準被 Node.js 所採用,所以才會有我們現在看到的這種形式。

有注意到上面我們一直在講 Node.js 嗎?

那瀏覽器呢?抱歉,瀏覽器原生不支援這個東西。所以你在瀏覽器上面,沒辦法用 module.exports,也沒辦法用 require

有些人會說:「你騙人!那為什麼我公司的專案用了這些也可以跑在瀏覽器上」,呵呵,我沒騙人,瀏覽器原生不支援沒錯,但你可以借助其他工具來達成這個目的(對,你現在知道為什麼要用 webpack 了吧)。

不過在介紹工具以前,我們先來試著自己解決看看這個問題。

手動加入 CommonJS 支援

在 CommonJS 模組標準裡面,最重要的就是兩個:

  1. module.exports
  2. require

前面有提到過:

我們在 utils.js 裡面 module.exports 什麼,在其他地方用 require('./utils') 就會引入什麼。所以可以把 var calculate = require('./utils') 想成 var calculate = (utils.js 裡的)module.exports

因此,我們可以試著加入一些程式碼,讓 require('./utils.js) 回傳的東西就是 utils.js 裡的 module.exports,就大功告成了。

第一步,先把原本 main.js 的內容用一個叫做 main 的 function 包住,並且傳入 require:

function main(require) {
  var obj = require('./utils')
  console.log(obj.cal(30)) // 9
  console.log(obj.name) // hello
}

再來我們也把 utils.js 的內容包住,因為 utils.js 沒有使用到 require,所以我們改成傳進去一個參數 module,會變成這樣:

function main(require) {
  var obj = require('./utils')
  console.log(obj.cal(30))
  console.log(obj.name)
}

function utils(module) {
  function calculate(n) { 
    return ((n * 100 + 20 - 4)) % 10 + 3
  }

  module.exports = {
    cal: calculate,
    name: 'hello'
  }
}

以上只是用兩個 function 把原本的兩個檔案包起來並且傳入參數而已。

接著我們可以在外面宣告一個變數 m,並且傳進去 utils 裡面然後呼叫:

function main(require) {
  var obj = require('./utils')
  console.log(obj.cal(30))
  console.log(obj.name)
}

function utils(module) {
  function calculate(n) { 
    return ((n * 100 + 20 - 4)) % 10 + 3
  }

  module.exports = {
    cal: calculate,
    name: 'hello'
  }
}

// 加入這兩行
var m = {}
utils(m)

這樣在呼叫完 utils 函式以後,m.exports 就是我們在 utils 函式裡面所輸出的東西,也就是 main 裡面的 require 呼叫之後應該回傳的內容。

所以最後一步,就是呼叫 main 函式並且傳入一個 require 的參數:

function main(require) {
  var obj = require('./utils')
  console.log(obj.cal(30))
  console.log(obj.name)
}

function utils(module) {
  function calculate(n) { 
    return ((n * 100 + 20 - 4)) % 10 + 3
  }

  module.exports = {
    cal: calculate,
    name: 'hello'
  }
}

var m = {}
utils(m)

// 加入底下這幾行
function r() {
  // 回傳我們所需要的 m.exports
  return m.exports
}
main(r)

這樣子就大功告成了,就可以把在 utils 裡面所輸出的 module.exports 丟給 main,並且在 require('utils.js') 的時候回傳。直接把上面這一整段程式碼貼到瀏覽器上面,真的能夠順利執行了!

上面這段程式碼只是大致上示範一下原理而已,原理基本上就是:

  1. 把檔案包成 function 並且接收 module 以及 require 等參數
  2. 在外面呼叫 function,傳入一個物件以及函式 require

不過實際上當然沒有那麼簡單,因為還有很多問題要解決,例如說:

  1. 相依性,例如說 A 依賴於 B,B 又依賴於 C,載入順序就必須是: C->B->A
  2. 載入多個模組,我們上面傳給 main 的函式只會回傳 utils 的 module.exports,但是應該要支援多個 require,所以 require 裡面要根據參數來決定回傳什麼
  3. 快取,當一個 module 被載入多次的時候應該要能夠快取起來

大概知道原理以後,我們就來看看現成的工具該如何使用。

browserify 介紹

前端的世界很簡單,說穿了就是一句話:

不支援的東西,寫工具自己支援就好了

Babel 如此,webpack 如此,PostCSS 也是如此,都是藉由工具來實現瀏覽器原生不支援的功能。

在 2011 年的時候,browserify 出現了。而官網上的第一句話就已經描述了它的用途:

Browserify lets you require('modules') in the browser by bundling up all of your dependencies.

簡單來說,就是讓你在瀏覽器上面使用 require。

可以在 terminal 上面使用以下指令,來打包我們剛剛那兩個檔案 main.js 與 utils.js:

npx browserify main.js -o bundle.js

傳入的第一個參數是所謂的入口點(entry point),就代表主要要執行的檔案。舉例來說,我們稍早在示範時都是用 node main.js 來執行,表示要執行的檔案其實是 main.js,因此 main.js 就是入口點。

然後產生的 bundle.js 內容如下:

(
  function e(t, n, r) {
    function s(o, u) {
      if (!n[o]) {
        if (!t[o]) {
          var a = typeof require == "function" && require;
          if (!u && a) return a(o, !0);
          if (i) return i(o, !0);
          var f = new Error("Cannot find module '" + o + "'");
          throw f.code = "MODULE_NOT_FOUND", f
        }
        var l = n[o] = {
          exports: {}
        };
        t[o][0].call(l.exports, function(e) {
          var n = t[o][1][e];
          return s(n ? n : e)
        }, l, l.exports, e, t, n, r)
      }
      return n[o].exports
    }
    var i = typeof require == "function" && require;
    for (var o = 0; o < r.length; o++) s(r[o]);
    return s
  })({
  1: [function(require, module, exports) {
    function calculate(n) {
      return ((n * 100 + 20 - 4)) % 10 + 3 // 計算價格公式
    }

    module.exports = {
      cal: calculate,
      name: 'hello'
    } // 把這個物件 export 出去
  }, {}],
  2: [function(require, module, exports) {
    var obj = require('./utils')
    console.log(obj.cal(30)) // 9
    console.log(obj.name) // hello
  }, {
    "./utils": 1
  }]
}, {}, [2]);

看不懂很正常,因為這是經過壓縮後的版本,但其實核心概念就是:「把你的程式碼用一個 function 包住,提供一個叫做 require 的 function 以及一個叫做 module 的物件給你使用」,說穿了跟我們上面做的事情差不多啦,只是更嚴謹了一些。

如果你真的很想看懂在幹嘛,我參考了原始碼:browser-pack/prelude.js,把上面那段打包出來的程式碼還原了一下並且加上註解,為了方便閱讀也更動了順序,也把一些額外的檢查跟功能拿掉了。

成果如下(請注意,底下為了教學目的只留下最核心的功能,其餘程式碼都拿掉了):

// 跟我們做的事情一樣,把檔案包成一個 function,傳入 require, modules
function utils(require, module) {
  function calculate(n) {
    return ((n * 100 + 20 - 4)) % 10 + 3
  }

  module.exports = {
    cal: calculate,
    name: 'hello'
  }
}

// 跟我們做的事情一樣,把檔案包成一個 function,傳入 require, modules
function main(require, module) {
  var obj = require('./utils')
  console.log(obj.cal(30))
  console.log(obj.name) 
}

/*
  定義一個叫做 modules 的物件,裡面把 module 換成數字編號
  陣列的第一個參數就是上面包好的 function,第二個參數則是相依性需要的 module 以及編號
  例如說: {
    "./utils": 1
  }
  代表說當我呼叫 require("./utils") 的時候,其實就是要載入編號為 1 的 module
*/
var modules = {
  1: [utils, {}],
  2: [main, {
    "./utils": 1
  }]
}

/*
  函式詳細內容可以見底下,第一個參數就是 modules
  第二個參數是 cache(先不管)
  第三個參數則是入口點,就像是 C 語言裡面的 main function 那樣
  以我們的範例來說,就是 main.js 這個檔案,也就是編號為 2 的 module
*/
outer(modules, {}, [2])

/* 
  底下程式碼來自:https://github.com/browserify/browser-pack/blob/master/prelude.js
  為了方便理解核心功能,有經過刪改
*/
function outer(modules, cache, entry) {
  /*
    順序執行 entry,在我們的例子 entry 只有一個
    所以可以簡單想成是:newRequire(2)
  */
  for (var i = 0; i < entry.length; i++) {
    newRequire(entry[i]);
  }

  /*
    核心程式碼在下面
    以我們的例子而言,name 會是 2
  */
  function newRequire(name) {
    //先從 cache 裡面找這個 module 的內容,找不到的話載入 module 並且放入 cache
    if (!cache[name]) {
      // 找不到要載入的 module,拋出錯誤
      if (!modules[name]) {
        var err = new Error('Cannot find module \'' + name + '\'');
        err.code = 'MODULE_NOT_FOUND';
        throw err;
      }

      /*
        宣告一個物件來儲存 module export 出來的東西
        並且一併放到 cache 裡面
      */
      var m = cache[name] = {
        exports:{}
      };

      /*
        核心功能就是底下這四行
        呼叫我們最前面定義的那個包好的 function,並且傳入 require 以及 module
        在 require 裡面會根據 modules 的內容找到要引入的 id
        以 require('./utils') 為例
        modules[2][1]['./utils'] 是 1,就會去載入 id 為 1 的 module 並且回傳
      */
      modules[name][0].call(m.exports, function(x){
          var id = modules[name][1][x];
          // 載入 module 並且回傳
          return newRequire(id ? id : x);
      }, m);
    }

    // 找到的話就直接回傳 module.exports
    return cache[name].exports
  }
}

原本的程式碼因為要考慮其他更多種情況所以比較複雜一些,只留下核心的模組功能差不多就長得像上面那樣。看不懂沒有關係,畢竟這是比較偏新手向的文章,只要稍微看過去就好。

你只要知道一個重點就行了:

瀏覽器原生並不支援 CommonJS(require 與 module.exports),一定要透過工具才能在瀏覽器上面使用。

上面提到的「工具」,我們已經介紹 browserify 了,但還有一個更有名的。

對,就是 webpack!

初探 webpack

剛才我們使用 browserify 的時候,使用了這個指令來指定入口點與打包出來的檔案名稱:

npx browserify main.js -o bundle.js

而 webpack 本質上與 browserify 相似,只是需要把這些變成設定檔。我們可以新增一個 webpack.config.js

module.exports = {
  entry: './main.js',
  output: {
    path: __dirname,
    filename: 'webpack_bundle.js'
  }
}

仔細觀察就會發現這其實跟使用 browserify 時要設定的選項是一樣的,入口點以及輸出的檔案名稱以及路徑(__dirname 代表跟 config 檔同一個目錄)。

接著在 terminal 上面執行這幾行指令,簡單來說就是先安裝 webpack 然後執行 webpack:

npm init -y
npm install webpack webpack-cli --save-dev
npx webpack --config webpack.config.js

接著就會看到目錄下多了一個 webpack_bundle.js,內容也是完全看不懂的東西。這是因為 webpack 有兩個模式,production 與 development,預設是前者。production 代表在生產環境下使用,所以會自動幫你壓縮以及優化。

在開發的時候通常會使用 development 這個模式,打包的速度較快。更改的方式很簡單,改變設定檔即可:

module.exports = {
  mode: 'development',
  entry: './main.js',
  output: {
    path: __dirname,
    filename: 'webpack_bundle.js'
  }
}

存檔以後再執行一次 npx webpack --config webpack.config.js,結果如下:

/******/ (function(modules) { // webpackBootstrap
/******/    // The module cache
/******/    var installedModules = {};
/******/
/******/    // The require function
/******/    function __webpack_require__(moduleId) {
/******/
/******/        // Check if module is in cache
/******/        if(installedModules[moduleId]) {
/******/            return installedModules[moduleId].exports;
/******/        }
/******/        // Create a new module (and put it into the cache)
/******/        var module = installedModules[moduleId] = {
/******/            i: moduleId,
/******/            l: false,
/******/            exports: {}
/******/        };
/******/
/******/        // Execute the module function
/******/        modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
/******/
/******/        // Flag the module as loaded
/******/        module.l = true;
/******/
/******/        // Return the exports of the module
/******/        return module.exports;
/******/    }
/******/
/******/
/******/    // expose the modules object (__webpack_modules__)
/******/    __webpack_require__.m = modules;
/******/
/******/    // expose the module cache
/******/    __webpack_require__.c = installedModules;
/******/
/******/    // define getter function for harmony exports
/******/    __webpack_require__.d = function(exports, name, getter) {
/******/        if(!__webpack_require__.o(exports, name)) {
/******/            Object.defineProperty(exports, name, { enumerable: true, get: getter });
/******/        }
/******/    };
/******/
/******/    // define __esModule on exports
/******/    __webpack_require__.r = function(exports) {
/******/        if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
/******/            Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
/******/        }
/******/        Object.defineProperty(exports, '__esModule', { value: true });
/******/    };
/******/
/******/    // create a fake namespace object
/******/    // mode & 1: value is a module id, require it
/******/    // mode & 2: merge all properties of value into the ns
/******/    // mode & 4: return value when already ns object
/******/    // mode & 8|1: behave like require
/******/    __webpack_require__.t = function(value, mode) {
/******/        if(mode & 1) value = __webpack_require__(value);
/******/        if(mode & 8) return value;
/******/        if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;
/******/        var ns = Object.create(null);
/******/        __webpack_require__.r(ns);
/******/        Object.defineProperty(ns, 'default', { enumerable: true, value: value });
/******/        if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key));
/******/        return ns;
/******/    };
/******/
/******/    // getDefaultExport function for compatibility with non-harmony modules
/******/    __webpack_require__.n = function(module) {
/******/        var getter = module && module.__esModule ?
/******/            function getDefault() { return module['default']; } :
/******/            function getModuleExports() { return module; };
/******/        __webpack_require__.d(getter, 'a', getter);
/******/        return getter;
/******/    };
/******/
/******/    // Object.prototype.hasOwnProperty.call
/******/    __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
/******/
/******/    // __webpack_public_path__
/******/    __webpack_require__.p = "";
/******/
/******/
/******/    // Load entry module and return exports
/******/    return __webpack_require__(__webpack_require__.s = "./main.js");
/******/ })
/************************************************************************/
/******/ ({

/***/ "./main.js":
/*!*****************!*\
  !*** ./main.js ***!
  \*****************/
/*! no static exports found */
/***/ (function(module, exports, __webpack_require__) {

eval("var obj = __webpack_require__(/*! ./utils */ \"./utils.js\")\nconsole.log(obj.cal(30)) // 9\nconsole.log(obj.name) // hello\n\n\n//# sourceURL=webpack:///./main.js?");

/***/ }),

/***/ "./utils.js":
/*!******************!*\
  !*** ./utils.js ***!
  \******************/
/*! no static exports found */
/***/ (function(module, exports) {

eval("function calculate(n) { \n  return ((n * 100 + 20 - 4)) % 10 + 3  // 計算價格公式\n}\n  \nmodule.exports = {\n  cal: calculate,\n  name: 'hello'\n} // 把這個物件 export 出去\n\n//# sourceURL=webpack:///./utils.js?");

/***/ })

/******/ });

跟 browserify 一樣,你把上面這整串貼到瀏覽器的 console 去,一樣可以正常執行並輸出結果。上面的程式碼不用細看,大致上滑過去就好,但你滑過去的時候就會發現有許多東西跟 browserify 打包出來的程式碼類似。

好,看到這邊相信大家已經知道了兩個重點:

  1. webpack 與 browserify 類似
  2. 要在瀏覽器上面使用 CommonJS 的模組機制,就必須使用工具先把程式碼打包才能做到

而第二點就是必須使用 webpack 的理由。

這時你可能會想問一個問題:

可是我們公司沒有用 require,而是用 ES6 的 import 與 export,這個瀏覽器不是支援了嗎?那為什麼需要 webpack?

問得很好,看到這邊可以先起身喝個水,下半場要開始了。

ES6 的標準化模組

前面有提到在 ES6 出現以前,JavaScript 並沒有一個標準的模組化規範。Node.js 支援 CommonJS,所以才可以用requiremodule.exports,但是瀏覽器原生沒有支援,所以才需要像是 browserify 以及 webpack 這種工具。

而 ES6 出來之後,終於有了正式的規範,就是大家常看到的 import 與 export,我們可以把之前的 main.js 與 utils.js 改成 import 與 export 的形式:

// main.js
import obj from './utils'
console.log(obj.cal(30))
console.log(obj.name)

//utils.js
function calculate(n) { 
  return ((n * 100 + 20 - 4)) % 10 + 3  // 計算價格公式
}

export default {
  cal: calculate,
  name: 'hello'
}

雖然說這是 ES6 的標準,但其實支援度還不是很好。

若是你在 Node.js 上面試圖執行 node main.js,會直接噴給你一個 SyntaxError: Unexpected identifier 的錯誤,因為 Node.js 不認識 import 這個語法。

想要在 Node.js 上面使用 import 與 export 的話有兩個方法,第一個方法是把副檔名從 .js 換成 .mjs,然後在使用 node 時加上一個 flag:node --experimental-modules main.mjs

順帶一提,這是 Node.js 版本 < 13 時的狀況,如果是最新的 13 版本以上,只要把檔名改成 mjs 就好,詳情可以參考:Node.js v13.7.0 Documentation: ECMAScript Modules

第二個方法就是大名鼎鼎的 babel 啦,靠著 babel 幫我們把 ES6 的語法轉成 ES5,可以直接使用在 babel 官網上提供的簡易線上轉換器,把 main.js 的內容從 import 轉成 require:

上面講的都是 Node.js 的使用,那瀏覽器呢?

可以先來試試看什麼都不要改變,一樣只有 main.jsutils.js,然後新增一個 index.html,內容如下:

<html>
<head>
  <script src="./main.js"></script>
</head>
<body>
</body>
</html>

打開之後會看到 devtool 的 console 吐了這樣一個錯誤:

Uncaught SyntaxError: Cannot use import statement outside a module

跟 Node.js 類似,若是想要使用 import 與 export,都必須以 module 的形式來執行,所以要在 script 標籤加上 type:

<html>
<head>
  <script src="./main.js" type="module"></script>
</head>
<body>
</body>
</html>

接著再次打開頁面,會發現還是有一個錯誤:

Access to script at 'file:///Users/huli/w_test/main.js' from origin 'null'
has been blocked by CORS policy: Cross origin requests are only supported for protocol schemes:
http, data, chrome, chrome-extension, https.

因為我目前打開 index.html 是直接點兩下打開,所以其實是開檔案而已,網址開頭會是 file:///。若是要使用 import 的話,必須要用伺服器的方式開啟才行。可以在同一個目錄底下輸入這行指令,簡單跑一個 file server:

python -m SimpleHTTPServer 8080

接著就可以打開:http://localhost:8080。

很不巧地,這次又出現了一個錯誤:GET http://localhost:8080/utils net::ERR_ABORTED 404 (File not found)

要把 main.js 裡的 import obj from './utils' 改成:import obj from './utils.js' 才行,要明確指定是要引入 .js 這個檔案。

改完之後重新整理,就可以在 console 上面看到正確的結果了!底下是完整的程式碼,只有三個檔案而已:

main.js

import obj from './utils.js'
console.log(obj.cal(30))
console.log(obj.name)

utils.js

function calculate(n) { 
  return ((n * 100 + 20 - 4)) % 10 + 3  // 計算價格公式
}

export default {
  cal: calculate,
  name: 'hello'
}

index.html

<html>
<head>
  <script src="./main.js" type="module"></script>
</head>
<body>
</body>
</html>

到這裡為止一切看似順利,好像沒什麼問題。在網頁上只要把引入的標籤多一個 type="module" 就可以跑起來了,很棒啊。

事情是沒有那麼簡單的。

第一個問題,瀏覽器支援度。

其實這個問題說大不大說小不小,端看貴公司有沒有要支援 IE,因為各大瀏覽器都有支援 import 與 export,可是 IE 沒有。

第二個問題,我想要使用 npm 上面其他人寫的套件的話怎麼辦?

在 Node.js 裡面沒有這個問題,因為你一定會在資料夾底下先裝好 node_modules,可是網頁呢?難道要把整個 node_modules 資料夾一起傳上去嗎?

還有一個問題是當你在 import 的時候,路徑要怎麼寫?難道要寫明是:import pad from './node_modules/pad-left/index.js' 這樣嗎?這是可維護性相當差的一個寫法,若是模組的入口點變了,你就必須改寫所有 import 的地方。

這個問題其實是相當麻煩的,因為在開發時通常都會用到其他人寫好的模組,若是沒辦法很方便地去支援引入這些模組,會造成很多不便利。

還記得我前面說過的話嗎?

「前端的世界很簡單,不支援的東西,寫工具自己支援就好了。」,這邊要稍微修改一下,變成:「前端的世界很簡單,不支援或是支援度很差的東西,寫工具自己支援就好了」。

就是因為瀏覽器原生的模組機制會碰到許多問題(相容性、無法兼容 npm 等等),所以我們才需要一個額外的工具。

而這個工具,就是 webpack。

再探 webpack

為了來體驗 webpack 的強大之處,我們先隨便找一個套件來安裝:

npm install pad-left

接著在 main.js 裡面引入套件並且使用:

import obj from './utils.js'
import pad from 'pad-left'
console.log(obj.cal(30))
console.log(pad('4', 4, 0))

然後一樣按照之前教過的流程打包檔案,config 檔之前已經寫過了,所以直接下指令就好:

npx webpack --config webpack.config.js

接著打開 index.html,更改引入的 script,因為有 webpack 的關係所以不需要 type=module 了:

<html>
<head>
  <script src="./webpack_bundle.js"></script>
</head>
<body>
</body>
</html>

最後打開 index.html,看到 console 上面出現 9 跟 0004 這兩個輸出,就代表有打包成功了。

所以使用 webpack 的好處之一,就是我們能把使用 npm 安裝的模組一併打包,就跟打包自己寫的模組一樣,不需要多做其他事情。這件事是原生的瀏覽器沒辦法做到的。

我們之所以需要 webpack,就是因為原生的瀏覽器模組功能跟支援度都沒那麼完整。

不過呢,其實 webpack 的強大之處不只這樣而已。打開 webpack 官網,會看到這張圖:

webpack 最厲害的地方在於它把「模組化」這個概念延伸了。我們剛剛在談論模組化的時候,都只有討論 JavaScript,討論程式上的模組。但 webpack 把「任何資源」都視成一個模組。

圖片是模組,所以你可以 import Image from './assets/banner.png';CSS 是模組,你可以 import styles from 'style.css',只要是任何資源都可以 import 進來使用。

這已經跟 JavaScript 與 ES6 一點關係都沒有了,這完全是 webpack 自己的延伸,在瀏覽器上面你是沒有辦法這樣使用的。

為了要支援這樣的功能,webpack 定義了許多 loader(載入器),不同的資源有不同的 loader,要透過 loader 處理才能把資源載入。而這個 loader 也是它強大的地方。

例如說你可以用 scss loader 載入 scss 的檔案,在引入資源的時候就會幫你順便編譯成 CSS,所以你不用自己做這件事。JS 也是一樣,你可以寫最新最潮的語法,然後在載入時用 babel-loader 把 ES10 的語法轉成 ES5。

這種資源的引入以及轉換,才是 webpack 最強大的地方。

還記得你在寫 React 的時候,把 import 圖片跟 CSS 當作吃飯喝水一樣嗎?好像這樣是很稀鬆平常的事。

你能夠寫出這種語法而且被支援,都是因為底層有 webpack 在幫你做處理,都是因為有 image loader 跟 css loader 特別處理你的 import。這不是瀏覽器原生就支援的東西,而是 webpack 的模組系統幫你做的事。

再者,掌握了資源的打包這一段之後,webpack 就可以藉由 loader 以及 plugin 做更多有趣的事,例如說:

  1. 在載入 JS 的時候順便做 uglify
  2. 在載入 CSS 的時候順便做 minify
  3. 把打包出來的檔名順便加上 hash
  4. 根據不同頁面打包不同的檔案,就不用一次載入全部 JS
  5. 支援動態引入 JS,有需要的時候才載入

webpack 的教學就到這邊了,我沒有打算繼續深講,再講下去就比較偏向工具的使用了。我寫這篇最主要的目的只是想讓你知道:

  1. 為什麼要用 webpack?
  2. 不用 webpack 的話會怎樣?
  3. webpack 與 ES6 定義的標準模組有什麼差別?
  4. webpack 的最基礎使用(寫設定檔與打包 JS)

Snowpack

前面我們有提到使用 webpack 的原因之一是原生的瀏覽器沒辦法打包 npm 安裝的模組,而 webpack 除了解決這個問題之外,還順便擴展了「模組」的定義,任何資源都可以視為一個模組,搭配強大的 loader 與 plugin 系統也可以做出更多有趣的事。

而近期剛好出了另外一個 library:Snowpack,標榜的就是不需要打包就能夠在瀏覽器上面跑,而原理十分簡單,其實就是幫你把 node_modules 安裝的模組整理了一下,放到另一個叫做 web_modules 的資料夾,要引入的時候去那邊引入就好了。

不過並不是每個模組都可以這樣,必須模組本身有支援標準的 ESM module 才行。像我們剛剛所使用的pad-left 就沒有,所以沒有辦法搭配 Snowpack 使用,因此我們等等會再裝一個有支援的 mathjs 來試試看。

我們立刻來體驗一下吧,先把 Snowpack 跟 mathjs 裝起來:

npm install --save-dev snowpack
npm install mathjs

接著執行 Snowpack,讓它把模組整理好:

npx snowpack

執行完之後,就會看到多了一個 web_modules 的資料夾,底下有兩個檔案:import-map.jsonmathjs.js

接著我們更新一下 main.js:

import obj from './utils.js'
import {pi} from './web_modules/mathjs.js' // 從 web_modules 資料夾引入
console.log(obj.cal(30))
console.log(pi)

然後更新 index.html:

<html>
<head>
  <script src="./main.js" type="module"></script>
</head>
<body>
</body>
</html>

最後一樣要用指令跑一個 server 起來:python -m SimpleHTTPServer 8080

發現 console 有出現 3.141592653589793,代表可以順利使用我們用 npm 安裝的模組了!

Snowpack 的使用方式就是那麼簡單,要解決的問題也很簡單,它只負責解決「引入第三方模組」這一塊,其他的不在它管理的範疇。

它能像 webpack 那樣子引入圖片跟 CSS 嗎?不行,而且官方網站直接建議你用以前的那些方法就好了:

在官網上也有誰應該要用 Snowpack 誰應該不要用的段落,例如說你想要支援 IE11 或是你需要用到不支援 ESM 的函式庫的話,就不應該使用 Snowpack。

Snowpack 還很新,而且若是想在 production 上面使用應該還有滿多問題要解決,我在這邊特別提它的目的是想讓大家看看除了 webpack 以外的另一種解決方式。

而你可能聽過的 parcel 或者是 rollup,都只是另外一種幫你打包的工具而已。你可以選一套工具來熟悉就好,但重點是在這之前,你必須清楚知道為什麼需要這些工具。

結語

先來回答開頭那幾個問題:

Q:為什麼很多專案(例如說 React)在部署前都要先 build?這個步驟在幹嘛你知道嗎?

因為原始碼沒辦法直接放上去瀏覽器(會沒有辦法執行),所以一定要經過 webpack 打包處理,打包完的檔案才能讓瀏覽器執行。

Q:你知道 require/module.exportsimport/export 的差別嗎?

require/module.exports 是一套叫做 CommonJS 的規範,Node.js 有支援,瀏覽器沒有。

import/export 是 ES6 的規範,Node.js 部分支援,瀏覽器也是部分支援。

Q:你知道 import 與 export 這兩個語法在瀏覽器上面不是隨便就能使用嗎?

有使用限制,例如說要加上 type=module,而且也沒辦法直接引入 npm 裡的模組,要把路徑寫死才能使用。而瀏覽器支援度也是一個考量,IE11 並不支援此種寫法。

Q:你知道為什麼要用 webpack 嗎?

原因有很多,例如說:

  1. 我想使用 npm 上的第三方模組
  2. 我想把圖片當作資源 import 進來
  3. 我想把 CSS 當作資源 import 進來
  4. 我想在一個地方就處理好 uglify 與 minify

不過重點其實是第一個,因為瀏覽器原生的 ES6 模組支援度沒那麼高,尤其是引入第三方模組,所以才需要透過 webpack 或其他打包工具幫我們處理好這一段。

Q:你知道 webpack 為什麼要有 loader 嗎?

因為把圖片或是 CSS 當作資源引入這並不是正式的規範,而是 webpack 自己延伸的定義。為了支援這些資源,就必須特別寫一個 loader 去載入,否則預設的 loader 只能載入 JavaScript。


我一開始接觸 webpack 時,也是超級霧煞煞。完全不知道為什麼要 webpack,也完全不知道它做了哪些魔法。

直到後來慢慢摸索才發現自己一開始就錯了,不該從 webpack 開始的。連模組化的概念都不清楚,連瀏覽器上面不能用 require 都不知道,怎麼可能理解 webpack 在幹嘛?

而前陣子接觸到的一些學生,儘管是有工作經驗或是有用過 webpack 的,對這一塊也是一知半解。而在我看來,原因就是對於模組化的理解不足,對於歷史脈絡的了解不夠,對於「在瀏覽器上面執行 JS」以及「在電腦上用 Node.js 執行 JS」這兩者的區分不夠清楚,才會把許多東西都混為一談,當作是同一種。

所以這篇雖然是叫做「webpack 新手教學」,但比較多在談的是模組化以及使用 webpack 的理由,對於真正「如何使用 webpack」並沒有加以著墨。原因之一是我認為理解原理跟理由之後,使用工具的門檻就會降低很多;之二是再寫下去就沒完沒了了,真的想講 webpack 的話可以再開一篇文章。

其實有關於模組化,我有很多東西刻意沒有提到,例如說 AMD/UMD 等等的其他規範以及 RequireJS 這個工具。我覺得對這一篇想表達的東西來說,不講這些反而是好的,因為一旦講了就會把事情搞得更複雜,所以選擇性忽略這些內容。

在寫作的時候,選擇什麼要講什麼不講也是一門技藝,或許日後可以再寫一篇文章來補齊沒有講到的這些部分。

最後,希望大家看完這篇以後真的能理解 ES6 的原生模組在瀏覽器上面會碰到的問題,就能知道為什麼要用 webpack。

若是有任何錯誤還麻煩不吝指正,感謝!

參考資料:

  1. 什麼?!我們竟然有 3 個標準? - 你有聽過 CommonJS 嗎?(Day9)

#Webpack #javascript









Related Posts

Return first capital letter and its index

Return first capital letter and its index

耦合過多的缺點

耦合過多的缺點

Export and import MongoDB collection

Export and import MongoDB collection




Newsletter




Comments